review: render the quoted skill rule into the comment the author reads#248
Conversation
…live cases Phase 1 of the live A/B eval plan (#232). The corpus gains an opt-in live block carrying what a real model run needs and the deterministic replay does not: - prContext (PR title/description/author/base; the description is untrusted author text, so an adversarial case can carry its payload there or in the diff), - a post-change file tree on disk next to the case, via a new <id>/case.json + <id>/tree/ layout that coexists with flat <id>.json (a directory containing case.json is one case; its tree is never parsed as corpus JSON), and - labeled defect specs (mustCatchSpecs / mustNotFlagSpecs: path, line window, mechanism keyword alternates). Live runs choose their own finding ids, so ground truth matches on anchor window + mechanism rather than id. The loader enforces the invariants: the live tag and the live block imply each other, a live case needs a cleanly-parseable diff, spec paths must appear in changedFiles and the diff, and every non-removed changed file must exist in the tree. loadLiveCorpus() returns the subset. The live half lives in corpus/live.ts; loader.ts re-exports it as the single public surface. Ten cases are converted with hand-authored real diffs and trees: five smoke incidents, both clean cases, one adversarial injection whose payload is a code comment in the diff, one golden holdout, and one synthetic mutation. Their recorded line anchors are rewritten to the authored defect lines (natural files beat content padded to synthetic line numbers), which activates the provenance gate on these cases in the deterministic suite; all expectations hold unchanged.
…action and case staging Phases 2a/2b of the live A/B eval plan (#232), stacked on the Phase 1 corpus format (#233). agent-extract.ts turns review.md's '## agent:' sections into data (name, description, pinned model, prompt body). It takes the markdown as a string with no fs access, because the baseline arm of an A/B reads the merge-base review.md via git show. Parsing is strict; a malformed or model-less section throws listing every problem, since a silently dropped agent would skew an eval arm without failing it. An integration test extracts the real review.md (21 agents) and pins the staging-root reference so a future rename fails a test instead of silently staging nothing. live-stage.ts materializes the production staging layout for one live-enabled corpus case: pr-context.json (review.md Step 1's shape, synthetic identity fields), full.diff / pr.diff / full-stripped.diff (all the case diff; corpus diffs carry no generated files and no pattern-triage pass runs), files.json + review-files.json with the hasPatch cross-check derived from the diff parse, provenance.json, routing.json from the deterministic router, an out/ directory, and the post-change checkout copied from the case tree. rewriteAgentPrompt swaps the production staging root for the staged context dir. Everything sits behind an injected-fs seam and is memfs-tested. Model dispatch (phase 2c) is deliberately absent; it needs the Agent SDK dependency decision and arrives separately.
… runner (phase 2c)
live-producer.ts runs the live roster over one staged case behind an
injected LiveAgentRunner seam (the judge.ts pattern), so its logic is
stub-tested with zero model calls. Roster: the default finders
(correctness-reviewer, skill-auditor) plus the router's lensesToSpawn;
no pattern-triage or thread-reconciler in eval. It maps all three
sub-agent output contracts into the shapes the deterministic runner
consumes: label-shape findings (correctness lens, or conventions for
the skill-auditor so labelForFinding reproduces the best-practice
variants; confidence defaulted to 0.7 per the production claims rule),
structured-schema lens findings validated as-is, and the validator's
{claims: [...]} verdicts into CaseVerification[]. It stages
claims.json for the validator, resolves {{#runtime-import}} directives
against the case tree (a case opts into a skills index by carrying the
file), retries once on malformed output with the rejection fed back,
keeps partial results when an agent fails twice, prefixes colliding
finding ids, and reports per-agent cost/turns/wall-clock.
live-runner.ts is the one module that touches a real model runtime:
an Agent SDK query() per dispatch with Read/Grep/Glob only, cwd pinned
to the staged checkout, the agent's pinned model, and hard turn and
wall-clock caps; plus the CLI smoke entry
(tsx live-runner.ts --case <id>, requires ANTHROPIC_API_KEY). The
investigation-cap CLI the prompts reference is not staged; its own
denied-budget fallback applies. Adds @anthropic-ai/claude-agent-sdk
as a dev dependency.
live-match.ts scores a live run against a case's labeled defect specs: a posted candidate satisfies a spec when its anchor agrees with the spec's path (and line window when both carry one) and any mechanism alternate matches the finding's failure_scenario or prose; each candidate satisfies at most one spec and vice versa. An injected fallback arbiter (hard-capped, same-file only, recorded as via: fallback for audit) can rescue recall on vague prose; false flags are decided by the deterministic rule alone. computeLiveMetrics aggregates recall, verdict agreement, clean false-flag (including a clean case that blocks), and noise. live-ab.ts is the arm orchestrator and CLI: baseline review.md from git show <merge-base>, candidate from the working tree, both arms over the same live corpus with everything else (corpus, lib, runner, metrics, judge) from the candidate, per the plan's settled decision to isolate the model seam. Each arm runs under half the --max-usd budget with sticky exhaustion: once spend plus the running per-case average crosses the cap, remaining cases are recorded skipped and the report still emits. Spec-level regressions are diffed only over cases both arms scored. Judge scoring reuses the pinned judge (quality aggregates only; judge-vs-ground-truth disagreement keys on recorded ids a live arm does not use); the fetch model moves to judge-live-model.ts and live-judge.ts now imports it. runner.ts gains an optional RunOptions.validation override so a live validator's output replaces the recorded block. Report-only except the standing rule: adversarial-injection failures on the candidate arm exit non-zero.
Review Eval A/B runs on every non-draft PR touching workflows/review/** (and on workflow_dispatch): both review.md arms over the live corpus via live-ab.ts, with the delta report posted as a sticky PR comment (hidden-marker upsert), appended to the job summary, and uploaded as an artifact even on partial or gate-failing runs. Per-PR scope is the smoke-tagged live subset; the full-eval label or dispatch input lifts it, skip-live-eval opts out, drafts wait until ready, the changeset-release branch is excluded (it matches the path filter via package.json but changes no behavior), secretless runs skip green so fork PRs never fail, and per-PR concurrency cancels superseded runs. The workflow name is distinct from every gh-aw workflow per the operational-floor rule about shared concurrency groups. live-ab.ts gains --smoke-only and writes out/live-ab-report.md alongside the JSON for the comment step.
… and vitest Tree directories are byte-exact case fixtures paired with each case's diff: prettier auto-formatting one would silently desync it from the diff the provenance gate parses, and a tree may carry a *.test.ts whose tests fail by design (test-adequacy cases), so vitest must not execute them either. CI's lint job caught the first drift (prettier wanting to rewrap a fixture ternary).
…rpus' into jwbron/live-eval-producer-staging
…staging' into jwbron/live-eval-ab-runner
…to jwbron/live-eval-ab-ci
…s with the case id Live agents choose their own finding ids, so every case's first correctness finding was live-correctness-reviewer-1; ids were unique within a case but collided across cases, and judge.ts's score join requires arm-global uniqueness. Caught by the first real A/B run (both arms completed, then judge aggregation threw). Ids are now <caseId>:<id> from the moment of parsing, so claims.json, the validator round-trip, the matcher, and the judge all see the same namespaced id.
…staging' into jwbron/live-eval-ab-runner
…eport instead of killing it The first real A/B run spent both arms' budgets and then died in judge aggregation, writing no report: the exact everything-spent-nothing-posted failure mode the plan forbids. Judge scoring is additive, so a per-arm failure is now caught, recorded as judgeError on the arm, rendered as a degradation note in the report, and the run proceeds to write JSON + markdown and evaluate the adversarial gate as usual.
…to jwbron/live-eval-ab-ci
…A/B phase 5) Packages the seeded-defect live-trial pattern (Khan/webapp#40678) as a Claude Code skill so a trial costs an operator an afternoon instead of a week of hand choreography. The skill collects required inputs (seeded branch, human-authored defect table, arms, budget approval) and refuses to improvise ground truth; sets up one isolated PR per arm with per-arm trigger recipes and the distinct-workflow-name rule (same-named gh-aw workflows share a per-PR concurrency group and cancel each other); collects reviews, artifacts, and costs per run with the known gh-aw artifact-bug tolerance; drives optional lifecycle pushes; scores defect by defect with the deterministic rule mirroring eval/live-match.ts plus audited manual judgment; exports live-enabled corpus case skeletons with a sanitization gate for private-repo content landing in this public repo; and cleans up trial PRs, branches, and temporary workflows. Trials remain the architecture-bet instrument; per-change evals belong to the corpus A/B. .gitignore narrows from .claude to .claude/* with a !.claude/skills/ carve-out: local agent state (settings, worktrees) stays untracked, project skills are shared tooling and are committed.
…orpus' into tmp-refresh
…roducer-staging' into tmp-refresh
…b-runner' into tmp-refresh
…b-ci' into tmp-refresh
…ity, code-rendered from the reconciler keep-list
… instead of dropping them
…oad (allowed-paths must match staging-relative paths)
🦋 Changeset detectedLatest commit: 57808d5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
…untability' into jwbron/review-out-of-lane
…into one staged disciplines file
8e015ff to
813767c
Compare
… into the comment the author reads
c415f4b to
7c07a42
Compare
… into jwbron/review-dispatch-tax
…-tax' into jwbron/review-skill-rule-quote
…ereview-accountability
This comment has been minimized.
This comment has been minimized.
# Conflicts: # workflows/review/eval/corpus/live.ts
Review Guidancegithub-actions (3 files)
Excluded from review (2 files)Not individually reviewed — generated, formatting-only, or
|
| "model_authored_prose": "the comment the author will read", | ||
| "suggested_patch": "optional", "pre_merge_obligation": "optional" | ||
| "suggested_patch": "optional", "pre_merge_obligation": "optional", | ||
| "rule_quote": "optional: for a skill finding, the exact rule text, verbatim" |
There was a problem hiding this comment.
I assume most of these aren't coming from skills - do we need to include this for them?
There was a problem hiding this comment.
Most aren't, right: the field is optional and a non-skill finding omits it entirely (the validator treats absence as fine). It's in each lens's example because any lens can own skills (the consumer's skills catalog maps skills to lenses by relevance criteria, so whichever lens owns the matched skill is the one that must emit rule_quote), and output examples are what the models actually copy; a field absent from the example tends to never get emitted. Cost is the one labeled line per definition.
| that produced the finding; `model_authored_prose` carries the entire human-read | ||
| comment. Omit `suggested_patch`/`pre_merge_obligation` unless they apply. | ||
| comment. Omit `suggested_patch`/`pre_merge_obligation` unless they apply; a skill | ||
| finding also carries `rule_quote` (§Lens-owned skills), which the orchestrator |
There was a problem hiding this comment.
It does resolve (there's a "## Lens-owned skills" heading ~40 lines above, in the same disciplines text the lens reads), but the notation was needlessly cryptic; spelled it out as "the Lens-owned skills section above" in 57d6fbf.
| the author, and `rule_quote` is what gets rendered into the comment they read (as | ||
| a `> **Rule:** …` blockquote), so the author sees the actual rule, not a |
There was a problem hiding this comment.
I don't think this cares about how it's rendered later...
There was a problem hiding this comment.
Agreed on the format detail: dropped the blockquote parenthetical in 57d6fbf. Kept the surrounding rationale (evidence traces never reach the author; rule_quote is what the author reads) since that's what tells the lens the field must be verbatim and author-facing rather than a paraphrase.
…review notes (spell out the section reference, drop the rendering detail)
| text from the skill file **and** the exact violating line; put both quotes in | ||
| `evidence_trace`, with no spirit-of-the-doc inference. | ||
| `evidence_trace`, with no spirit-of-the-doc inference. Also copy the exact rule | ||
| text, verbatim, into the finding's `rule_quote` field: evidence traces never reach |
There was a problem hiding this comment.
question (non-blocking): Skill findings now render two ways depending on which agent caught them: a lens surfaces the rule as a > **Rule:** blockquote, while the skill-auditor still puts both quotes inline in the finding's discussion (review.md ~1698-1700) with no blockquote. Is that presentation asymmetry intended? If so, consider either having the skill-auditor emit rule_quote too (its findings flow through the same normalization) or noting in the changeset why the asymmetry is deliberate — the PR frames "skill-auditor path untouched" as scope discipline, which explains why the rule is still visible but not why it's formatted differently.
… inside its blockquote
…on prefix (review feedback)
| const label = labelForFinding(finding); | ||
| const lines: string[] = [`**${label}:** ${finding.model_authored_prose}`]; | ||
|
|
||
| if (finding.rule_quote !== undefined) { |
There was a problem hiding this comment.
thought (non-blocking): The changeset, JSDoc, and review.md all describe rule_quote as "skill-file text copied verbatim," but nothing enforces that: it's validated only as a non-empty string (finding-schema.ts), and it isn't carried into claims.json, so the claim-validator — which does read the skill file for skill claims — never compares the rendered quote against the file. A lens that rewords or invents a rule would surface that text to the author inside an authoritative > **Rule:** blockquote. If the verbatim guarantee is meant to be load-bearing, consider riding rule_quote into the validator's skill re-check (or a code-side substring check before rendering); otherwise it's a trusted-copy field like model_authored_prose, which is a fine choice but worth stating as the intent.
…/main' into jwbron/review-skill-rule-quote # Conflicts: # workflows/review/lib/disciplines.test.ts # workflows/review/review.md
Summary
Fifth and last PR of the fold-in batch two stack (stacked on #247; the stack is based on #238,
jwbron/review-trial-skill). This branch,jwbron/review-skill-rule-quote, is the TOP of the batch-two stack; downstream work (the re-review mode dial, the webapp preview runs) should base on it.Quote-the-rule requires a lens skill finding to carry the exact rule text, but it lands in
evidence_trace, which never reaches the PR; the author reads onlymodel_authored_prose, a paraphrase of a rule they cannot check.Failure scenario fixed: an author gets "suggestion (non-blocking): Datastore reads here should be strongly consistent per the datastore skill", has no idea what the skill actually says, either takes the bot's word for it or spends time hunting down the skill file, and cannot tell a faithful application from an overreach.
The change:
Findinggains an optionalrule_quote(the exact rule text, verbatim from the skill file). Optional, validated non-empty when present;FINDING_SCHEMA_VERSIONstays 2 (no serialized finding is invalidated).evidence_tracequotes, and the lens output JSON examples name the field.renderCommentand the orchestrator's normalization step surface it into the posted comment as a> **Rule:** <quote>blockquote between the prose and any suggestion block. Only the wrapping is code-owned; the quote is skill-file text copied verbatim, per the determinism boundary.The skill-auditor path is untouched: its prompt already requires both quotes inside
discussion, which posts as-is.cc @jeresig
Testing
npx vitest run workflows/review: 513 tests green (5 new: blockquote rendering and ordering, absent-field behavior, schema acceptance/rejection).npx tsc --noEmitclean.